跳到主要内容

Go 的 select 使用与多路复用

select 是 Golang 在语言层面提供的多路 IO 复用的机制,其可以检测多个 channel 是否 ready(即是否可读或可写),使用起来非常方便。

回顾 I/O 多路复用的概念

下面是 Linux 的 I/O 多路复用模型:

IO multiplexing 就是我们说的 select,poll,epoll,有些地方也称这种 IO 方式为 event driven IO(事件驱动 IO)。

select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select,poll,epoll 这个 function 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。

当用户进程调用了 select,那么整个进程会被 block,而同时,内核会 “监视” 所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。

这个时候用户进程再调用 read 操作,将数据从内核拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select() 函数就可以返回。

这个图和 blocking IO 的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个 system call(select 和 Socket 调用(recvfrom)),而 blocking IO 只调用了一个 system call(Socket 调用)。

但是,用 select 的优势在于它可以同时处理多个 connection。

所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 多线程 + blocking IO 的 web server 性能更好,可能延迟还更大。

select / epoll 的优势 并不是对于单个连接能处理得更快,而是在于能处理更多的连接

在 IO multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。

select 的使用

下面的程序输出是什么?

package main

import (
"fmt"
"time"
)

func main() {
chan1 := make(chan int)
chan2 := make(chan int)

go func() {
chan1 <- 1
time.Sleep(5 * time.Second)
}()

go func() {
chan2 <- 1
time.Sleep(5 * time.Second)
}()

select {
case <-chan1:
fmt.Println("chan1 ready.")
case <-chan2:
fmt.Println("chan2 ready.")
default:
fmt.Println("default")
}

fmt.Println("main exit.")
}

select 中各个 case 执行顺序是随机的,如果某个 case 中的 channel 已经 ready,则执行相应的语句并退出 select 流程,如果所有 case 中的 channel 都未 ready,则执行 default 中的语句然后退出 select 流程。另外,由于启动的协程和 select 语句并不能保证执行顺序,所以也有可能 select 执行时协程还未向 channel 中写入数据,所以 select 直接执行 default 语句并退出。所以,以下三种输出都有可能:

可能的输出一:

chan1 ready.
main exit.

可能的输出二:

chan2 ready.
main exit.

可能的输出三:

default
main exit.

Golang 网络包中的多路复用

当涉及到处理并发的网络连接或文件 I/O 时,I/O 多路复用是一种常用的技术。它允许单个线程或进程同时监视和处理多个 I/O 事件,从而提高系统的并发性和效率。

一个经典的例子是基于事件驱动的服务器,其中 I/O 多路复用用于同时处理多个客户端连接。下面是一个简单的示例代码,演示如何使用 Golang 的 select 语句和 net 包来实现基于 I/O 多路复用的并发服务器:

package main

import (
"fmt"
"log"
"net"
)

func main() {
listener, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
defer listener.Close()

fmt.Println("Server is running on http://localhost:8080")

connections := make(chan net.Conn)
disconnections := make(chan net.Conn)

// 监听新的连接
go func() {
for {
conn, err := listener.Accept()
if err != nil {
log.Println(err)
continue
}
connections <- conn
}
}()

// 处理连接和断开连接
for {
select {
case conn := <-connections:
fmt.Printf("New client connected: %s\n", conn.RemoteAddr())
go handleConnection(conn, disconnections)
case conn := <-disconnections:
fmt.Printf("Client disconnected: %s\n", conn.RemoteAddr())
}
}
}

func handleConnection(conn net.Conn, disconnections chan<- net.Conn) {
defer conn.Close()

// 处理连接的业务逻辑
// ...

disconnections <- conn
}

在上述示例中,我们创建了一个 TCP 监听器 listener,并使用 listener.Accept() 接受客户端连接。通过在单独的 goroutine 中监听新的连接,我们将每个新的连接发送到 connections 通道中。

在主循环中,我们使用 select 语句来等待来自 connectionsdisconnections 通道的事件。当有新的连接时,我们创建一个新的 goroutine 来处理该连接,并将连接发送到 handleConnection 函数中。在处理连接期间,我们可以执行任何自定义的业务逻辑。

当客户端断开连接时,我们将连接发送到 disconnections 通道中。这样,我们可以在主循环中处理断开连接的事件。

通过这种方式,我们可以使用单个线程同时处理多个客户端连接,而无需为每个连接创建一个新的线程。这提供了更高的并发性,并减少了资源的开销。

这就是一个简单的示例,展示了如何使用 Golang 的 select 语句和 net 包实现基于 I/O 多路复用的并发服务器。实际的服务器实现可能更加复杂,涉及更多的网络协议处理和业务逻辑。但这个例子可以帮助你理解 I/O 多路复用的基本概念和用法。